跳到主要内容

Docker 与 containerd 的关系

平时敲的每一条 docker 命令,背后都不是 Docker 一个进程在干活,而是一整条组件接力链。containerd 就是这条链上最关键的一环:它原本只是 Docker 的内部组件,后来独立成 CNCF 毕业项目,如今还是 Kubernetes 节点上的主流容器运行时。这篇附录讲清楚三件事:这条调用链长什么样、它是怎么一步步演进成这样的,以及如何绕开 Docker 单独使用 containerd。

调用链​

以 docker run 为例,一条命令从敲下到容器跑起来,请求会沿着下面这条链一路传递:

docker CLI → dockerd → containerd → containerd-shim → runc → 容器进程

各组件的职责:

组件是什么职责
docker CLI命令行客户端用户入口,把命令通过 REST API(默认走 /var/run/docker.sock)发给 dockerd,自己不做实际工作
dockerdDocker 守护进程对外提供 REST API,管理镜像、网络、数据卷、构建等上层功能,通过 gRPC 调用 containerd
containerd容器运行时管理器负责镜像的拉取与存储、容器生命周期管理,是上层工具和底层运行时之间的桥梁
containerd-shim垫片进程,每个容器一个充当容器进程的「父进程」,让容器脱离 dockerd 和 containerd 独立存活,Daemon 重启或升级时容器不受影响
runcOCI 运行时参考实现按 OCI 规范真正创建容器:配置 Namespace、Cgroups,启动容器内进程,做完就退出,把进程交给 shim 看管
容器进程普通 Linux 进程最终跑起来的应用本身,本质就是宿主机上一个被隔离的进程

执行 docker run -d --name demo nginx 的完整过程:

  1. docker CLI 把请求通过 /var/run/docker.sock 发给 dockerd。
  2. dockerd 通过 gRPC 通知 containerd 创建容器。
  3. containerd 检查本地有没有 nginx 镜像,没有就先从镜像仓库拉取。
  4. containerd 为这个容器拉起一个 containerd-shim 进程。
  5. shim 调用 runc,runc 按 OCI 规范配好 Namespace 和 Cgroups,启动容器内的 nginx 进程。
  6. runc 完成任务退出,shim 留下来当容器进程的父进程,负责传递标准输入输出、回收退出状态。
备注

为什么要多一个 shim?如果容器进程直接挂在 containerd 下面,containerd 一旦升级或崩溃重启,所有容器都会跟着被杀。有了 shim 这层垫片,容器就有了自己的「监护人」,升级 Docker、重启 Daemon 都不会影响正在运行的容器。

这条链不是理论,在 Linux 宿主机上可以直接看到:

# 先启动一个常驻容器
docker run -d --name demo nginx

# 查看 dockerd、containerd、shim 进程
ps aux | grep -E 'dockerd|containerd|shim' | grep -v grep

输出大致如下:

root  1009  ... /usr/bin/containerd
root 1010 ... /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
root 2345 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 3f4a5b... ...

注意 dockerd 启动参数里的 --containerd=/run/containerd/containerd.sock,这就是 dockerd 和 containerd 之间的 gRPC 通道。再验证链路的最后一段——容器进程的父进程确实是 shim:

# 找到容器主进程(nginx)在宿主机上的 PID
docker inspect -f '{{.State.Pid}}' demo

# 查看它的父进程 PID(PPID 一列,对应上面的 shim 进程)
ps -o pid,ppid,cmd -p $(docker inspect -f '{{.State.Pid}}' demo)
提示

Mac 和 Windows 上的 Docker Desktop 把这些进程跑在一个内置 Linux 虚拟机里,宿主机终端直接 ps 是看不到的,需要在真正的 Linux 服务器上验证。

架构演进​

Docker 不是一开始就这么分层的,这套架构是十多年间一步步拆出来的:

时间事件
2013Docker 开源。早期是单体架构:dockerd 一个进程包揽所有事,底层最初基于 LXC,后来换成自研的 libcontainer
2015Docker 联合多家公司成立 OCI(Open Container Initiative),制定镜像和运行时两大标准;同时把 libcontainer 独立出来捐给 OCI,成为参考实现 runc
2016Docker 1.11 把容器管理功能从 dockerd 中拆出为独立组件 containerd,架构从此变成 dockerd → containerd → runc
2017Docker 把 containerd 捐赠给 CNCF(云原生计算基金会),containerd 成为社区中立的容器运行时项目
2019containerd 从 CNCF 毕业,标志着它足够成熟、可用于生产环境
2020Kubernetes 1.20 宣布弃用 dockershim(K8s 中专门适配 Docker 的垫片层),推荐直接使用 containerd 等 CRI 运行时
2022Kubernetes 1.24 正式移除 dockershim,containerd 成为 K8s 节点上的主流容器运行时
为什么要拆?

早期 dockerd 什么都管:API 服务、镜像、网络、存储、构建、容器运行……任何一处升级或崩溃都会波及所有容器,其他工具想复用容器能力也只能整套依赖 Docker。拆分之后每层职责单一:dockerd 管上层功能,containerd 专心管容器生命周期,runc 只管按 OCI 标准创建容器。下层组件稳定且标准化,Kubernetes 这类平台可以直接对接 containerd,绕过整个 Docker。

备注

「K8s 弃用 Docker」弃掉的只是 dockershim 这个适配层,不是 Docker 本身。Docker 构建的镜像完全符合 OCI 标准,在使用 containerd 运行时的 K8s 集群里照常运行;日常开发继续用 Docker 也没有任何问题。

单独使用 containerd​

containerd 既然能脱离 dockerd 独立工作,就完全可以单独使用——Kubernetes 节点上通常就是这样:只装了 containerd,没有 Docker。这时有两个命令行工具可用:

  • nerdctl:containerd 社区维护的 Docker 风格 CLI(名字即「containerd 的 ctl」),命令和参数与 docker 几乎一模一样,还支持配合 BuildKit 构建镜像、用 nerdctl compose 跑 Compose 文件,适合日常使用。
  • ctr:containerd 自带的底层调试工具,功能简陋、输出不友好,仅供排查 containerd 本身的问题,不适合日常操作。

常用命令对照​

同一个操作,三个工具各自的写法:

操作dockernerdctlctr
查看镜像docker imagesnerdctl imagesctr images ls
拉取镜像docker pull nginxnerdctl pull nginxctr images pull docker.io/library/nginx:latest
运行容器docker run -d --name web nginxnerdctl run -d --name web nginxctr run -d docker.io/library/nginx:latest web
查看容器docker psnerdctl psctr containers ls
查看运行中的任务docker psnerdctl psctr tasks ls
进入容器docker exec -it web shnerdctl exec -it web shctr tasks exec --exec-id sh1 -t web sh
查看日志docker logs webnerdctl logs web(不支持)
停止容器docker stop webnerdctl stop webctr tasks kill web
删除容器docker rm webnerdctl rm webctr containers rm web
构建镜像docker build -t app .nerdctl build -t app .(不支持,需单独使用 BuildKit)

从这张表能看出 ctr 的几处「不友好」:

  • 镜像名必须写完整地址(docker.io/library/nginx:latest),不像 docker 会自动把 nginx 补全成 Docker Hub 官方镜像。
  • 容器(containers)和任务(tasks)是两个概念:containers 是静态的容器定义,tasks 才是真正跑起来的进程,看运行状态要用 ctr tasks ls。
  • 没有 logs,没有 build,连 ctr run 之前都得先手动 ctr images pull。
备注

ctr 的设计目标本来就不是给人日常用的,它是 containerd 的调试入口。想脱离 Docker 日常操作 containerd,装一个 nerdctl 就够了,使用习惯和 docker 完全一致。

namespace 隔离​

containerd 内部用 namespace 把不同使用方的数据隔开,互不干扰:

namespace使用方
mobyDocker(dockerd 创建的容器都在这里)
k8s.ioKubernetes(kubelet 创建的 Pod 和容器)
defaultctr 和 nerdctl 默认操作的 namespace

所以在 K8s 节点上直接用 ctr,默认是什么都看不到的,要加 -n 指定 namespace:

# 查看 containerd 里有哪些 namespace
ctr namespaces ls

# 查看 K8s 创建的容器
ctr -n k8s.io containers ls

# nerdctl 同理
nerdctl -n k8s.io ps

K8s 节点上排查为什么用 crictl​

在 K8s 节点上排查容器问题,更顺手的工具其实是 crictl,原因有三:

  1. 它和 kubelet 看的是同一份数据。crictl 走的是 CRI(容器运行时接口),和 kubelet 调用 containerd 用的是同一套接口,所以它看到的就是 kubelet 眼中的容器状态。
  2. 它认识 Pod。ctr 和 nerdctl 站在 containerd 层面,只知道容器、不知道 Pod;crictl 能列出 Pod 沙箱(sandbox),「这个容器属于哪个 Pod」一目了然。
  3. 运行时无关。不管节点上装的是 containerd 还是 CRI-O,crictl 的命令都一样。

常用命令:

crictl ps                 # 查看运行中的容器,类似 docker ps
crictl pods # 查看 Pod 沙箱
crictl images # 查看镜像
crictl logs <容器ID> # 查看容器日志
crictl inspect <容器ID> # 查看容器详情
提示

crictl 需要知道 CRI 端点才能工作,一般写在 /etc/crictl.yaml 里:

runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10

不配的话每次都要加 --runtime-endpoint 参数。配好之后用法和 docker 基本一致,排查「容器起不来」「镜像拉不下来」这类问题非常顺手。

注意

在 K8s 节点上不要用 ctr 或 nerdctl 直接修改、删除 K8s 管理的容器——这些操作 kubelet 完全不知情,会导致集群状态和实际状态对不上。用 ctr 只做只读排查(ls、ps),要动手操作就通过 kubectl 或 crictl。